---
title: "Dashboard_surf"
author: "DOMINGO MARCELLIN Giovanni"
date: "2026-01-09"
output:
flexdashboard::flex_dashboard:
orientation: rows
vertical_layout: fill
theme: flatly
source_code: embed
---
```{r setup-ui, include=FALSE}
library(plotly)
library(ggplot2)
library(dplyr)
library(DT)
library(flexdashboard)
library(htmltools)
URL <- "https://www.surf-report.com/meteo-surf/lacanau-s1043.html"
# Force working dir = folder of this Rmd (critical in render)
input_file <- knitr::current_input()
root_dir <- if (!is.null(input_file) && nzchar(input_file)) {
dirname(normalizePath(input_file))
} else {
getwd()
}
knitr::opts_knit$set(root.dir = root_dir)
setwd(root_dir)
# Paths
PY_SCRIPT <- normalizePath(file.path(root_dir, "../python/run_surf_scrap.py"), mustWork = FALSE)
CSV_OUT <- normalizePath(file.path(root_dir, "../data/data_surf.csv"), mustWork = FALSE)
LOG_FILE <- file.path(root_dir, "python_run.log")
if (!file.exists(PY_SCRIPT)) {
stop("Python script not found: ", PY_SCRIPT,
"\nPut run_surf_scrap.py in the SAME folder as this dashboard.Rmd.")
}
PYTHON_EXE <- Sys.which("python")
if (PYTHON_EXE == "") {
stop("Python not found in PATH. Set PYTHON_EXE manually if needed.")
}
# Dependency check (NO installs during render)
code <- "import pandas, requests; print('OK')"
chk <- system2(PYTHON_EXE, args = c("-c", shQuote(code)), stdout = TRUE, stderr = TRUE)
if (!any(grepl("^OK$", chk))) {
stop("Python dependencies missing. Install once: python -m pip install pandas requests")
}
```
```{r dashboard-css, echo=FALSE}
htmltools::tags$style(htmltools::HTML("
/* Réduit les marges/paddings globaux */
.section.level2 { padding-top: 6px; }
.chart-wrapper { padding: 8px 10px 10px 10px; }
/* ValueBox: plus compact + texte mieux aligné */
.value-box {
border-radius: 10px;
box-shadow: 0 1px 6px rgba(0,0,0,0.08);
}
.value-box .value { font-size: 34px; font-weight: 700; }
.value-box .caption { font-size: 14px; opacity: 0.9; }
/* Tables DT: police + densité */
.dataTables_wrapper { font-size: 13px; }
table.dataTable tbody td { padding: 6px 8px; }
"))
```
```{r doc-modal, echo=FALSE}
library(htmltools)
# 1) HTML de la modal (Bootstrap)
doc_modal <- tags$div(
class = "modal fade", id = "docModal", tabindex = "-1",
role = "dialog", `aria-labelledby` = "docModalLabel", `aria-hidden` = "true",
tags$div(
class = "modal-dialog modal-lg", role = "document",
tags$div(
class = "modal-content",
tags$div(
class = "modal-header",
tags$h4(class = "modal-title", id = "docModalLabel", "Documentation"),
tags$button(
type = "button", class = "close", `data-dismiss` = "modal", `aria-label` = "Close",
tags$span(`aria-hidden` = "true", HTML("×"))
)
),
tags$div(
class = "modal-body", style = "font-size:14px; line-height:1.45;",
tags$h4("Objective"),
tags$p(
"This dashboard provides a decision-oriented view of the next-week surf forecast for Lacanau. ",
"It highlights the best time slot to surf, the highest expected wave conditions, ",
"wave and wind time series, and a summary table of forecasts."
),
# --- Source code (GitHub) ---
tags$h4("Source code (GitHub)"),
tags$p(HTML(
"Repository: <a href='https://github.com/echo-charbel/surf-dashboard-r-python.git' target='_blank'>https://github.com/echo-charbel/surf-dashboard-r-python.git</a>"
)),
tags$h4("How to run"),
tags$ol(
tags$li(HTML("Place <code>dashboard.Rmd</code> and <code>run_surf_scrap.py</code> in the same folder.")),
tags$li(HTML("Ensure Python is available in PATH and install the required Python packages: <code>pandas</code> and <code>requests</code>.")),
tags$li(HTML("Open <code>dashboard.Rmd</code> in RStudio and click <b>Knit</b> / <b>Render</b>.")),
tags$li(HTML("During rendering, the dashboard runs the Python scraper, generates <code>data_surf.csv</code>, then computes KPIs and renders the visuals.")),
tags$li(HTML("If the render fails, check <code>python_run.log</code> for Python output and errors."))
),
tags$h4("Data source"),
tags$p(HTML(
"Forecasts are scraped from: <code>https://www.surf-report.com/meteo-surf/lacanau-s1043.html</code>.<br/>
The Python script (<code>run_surf_scrap.py</code>) extracts the forecast table and writes <code>data_surf.csv</code>."
)),
tags$h4("Inputs / outputs"),
tags$ul(
tags$li(HTML("<b>Input:</b> Surf-Report HTML page (URL).")),
tags$li(HTML("<b>Output:</b> <code>data_surf.csv</code> generated by the Python script.")),
tags$li(HTML("<b>Logs:</b> <code>python_run.log</code> stores Python stdout/stderr to help troubleshooting."))
),
tags$h4("Expected file structure"),
tags$p("The dashboard expects the following files to be available at render time:"),
tags$ul(
tags$li(tags$code("dashboard.Rmd")),
tags$li(tags$code("run_surf_scrap.py")),
tags$li(HTML("<code>data_surf.csv</code> (generated at runtime)")),
tags$li(HTML("<code>python_run.log</code> (generated at runtime)"))
),
tags$h4("Dependencies"),
tags$p(HTML(
"<b>R:</b> flexdashboard, ggplot2, plotly, DT, dplyr, htmltools<br/>
<b>Python:</b> pandas, requests (Python must be available in PATH)"
)),
tags$h4("Pipeline"),
tags$ol(
tags$li(HTML("Run the Python scraper (<code>run_surf_scrap.py</code>) to generate <code>data_surf.csv</code>.")),
tags$li("Load the CSV into R and validate required columns."),
tags$li(HTML(
"Parse and transform raw strings into numeric variables:
<ul>
<li><code>Wave_Size_Mean</code>: mean of wave range (e.g., “0.8m - 0.7m” → 0.75).</li>
<li><code>Wind_speed_num</code>: numeric wind speed (km/h).</li>
<li><code>DateTime</code>: reconstructed timestamp from French day + time.</li>
</ul>"
)),
tags$li("Compute KPIs (best moment, sea quality score, highest wave) and render charts and summary table.")
),
tags$h4("Data dictionary (main variables)"),
tags$ul(
tags$li(HTML("<code>Date</code>, <code>Time</code>: forecast day label (French) and time slot.")),
tags$li(HTML("<code>Wave_size</code>: raw wave range string scraped from the website (e.g., “0.8m - 0.7m”).")),
tags$li(HTML("<code>Wind_speed</code>: raw wind speed string scraped from the website (e.g., “24 km/h”).")),
tags$li(HTML("<code>Wind_direction</code>: raw wind direction label (e.g., “Ouest Nord Ouest”).")),
tags$li(HTML("<code>Wave_Size_Mean</code>: numeric mean of the wave range (meters).")),
tags$li(HTML("<code>Wind_speed_num</code>: numeric wind speed (km/h).")),
tags$li(HTML("<code>DateTime</code>: reconstructed timestamp used for plotting.")),
tags$li(HTML("<code>Quality</code>: surf quality score in 0–9 based on direction, waves, and wind speed."))
),
tags$h4("Sea quality score (0–9)"),
tags$p("The score is a simplified rule-based indicator (assignment-oriented), computed as:"),
tags$ul(
tags$li(HTML("<b>Direction:</b> +3 if wind direction contains “Nord”, else 0")),
tags$li(HTML("<b>Waves:</b> +3 if ≤ 1.0m; +2 if ≤ 1.5m; +1 if ≤ 2.0m; else 0")),
tags$li(HTML("<b>Wind speed:</b> +3 if ≤ 10; +2 if ≤ 25; +1 if ≤ 50; else 0"))
),
tags$p(HTML(
"Total: <code>Quality = score_dir + score_wave + score_wind</code> in {0,…,9}. ",
"The gauge displays <code>100 × Quality/9</code>."
)),
tags$h4("Best moment selection rule"),
tags$p(
"The dashboard selects the time slot that maximizes the Quality score. ",
"If a strict 'Nord' constraint is enabled, selection is performed only among slots whose wind direction contains “Nord”."
),
tags$h4("Debugging / troubleshooting"),
tags$ul(
tags$li(HTML("If <code>data_surf.csv</code> is missing after render, inspect <code>python_run.log</code> (Python stdout/stderr).")),
tags$li("If Python is not found, ensure it is installed and available in PATH (or configure the Python executable used by the dashboard)."),
tags$li("If the website structure changes, the Python scraper may require updates (HTML selectors).")
),
tags$h4("Limitations"),
tags$p(
"Quality is a simplified score and does not represent a physical ocean model. ",
"Wind direction detection is based on text matching; wave ranges are summarized by their mean."
)
)
)
)
)
# 2) JS: ajoute un lien "Documentation" dans la navbar, à côté de "Source Code"
add_button_js <- tags$script(HTML("
(function(){
function addDocLink(){
var nav = document.querySelector('.navbar-nav');
if(!nav) return;
if(document.getElementById('docNavBtn')) return;
var li = document.createElement('li');
li.id = 'docNavBtn';
li.innerHTML = '<a href=\"#\" data-toggle=\"modal\" data-target=\"#docModal\">Documentation</a>';
// Insert before Source Code link if found, else append
var items = nav.querySelectorAll('li');
for (var i=0; i<items.length; i++){
var a = items[i].querySelector('a');
if(a && a.textContent && a.textContent.toLowerCase().indexOf('source code') !== -1){
nav.insertBefore(li, items[i]);
return;
}
}
nav.appendChild(li);
}
document.addEventListener('DOMContentLoaded', function(){
addDocLink();
setTimeout(addDocLink, 300);
setTimeout(addDocLink, 1200);
});
})();
"))
tagList(doc_modal, add_button_js)
```
```{r}
cmd_args <- c(
shQuote(PY_SCRIPT),
"--url", shQuote(URL),
"--out", shQuote(CSV_OUT)
)
res <- tryCatch(
system2(PYTHON_EXE, args = cmd_args, stdout = TRUE, stderr = TRUE),
error = function(e) paste("system2() error:", conditionMessage(e))
)
writeLines(res, con = LOG_FILE)
if (!file.exists(CSV_OUT)) {
stop(
"CSV file not found after running Python.\nExpected: ", CSV_OUT,
"\nSee python log: ", normalizePath(LOG_FILE, winslash = "/", mustWork = FALSE),
"\n\nLast python output:\n", paste(tail(res, 80), collapse = "\n")
)
}
```
```{r}
cmd_args <- c(
shQuote(PY_SCRIPT),
"--url", shQuote(URL),
"--out", shQuote(CSV_OUT)
)
res <- tryCatch(
system2(PYTHON_EXE, args = cmd_args, stdout = TRUE, stderr = TRUE),
error = function(e) paste("system2() error:", conditionMessage(e))
)
writeLines(res, con = LOG_FILE)
if (!file.exists(CSV_OUT)) {
stop(
"CSV file not found after running Python.\nExpected: ", CSV_OUT,
"\nSee python log: ", normalizePath(LOG_FILE, winslash = "/", mustWork = FALSE),
"\n\nLast python output:\n", paste(tail(res, 80), collapse = "\n")
)
}
```
```{r}
raw <- read.csv(CSV_OUT, stringsAsFactors = FALSE)
# Normalize column names if needed
rename_map <- c(
"Day" = "Date",
"Hour" = "Time",
"Waves_size" = "Wave_size",
"WavesSize" = "Wave_size",
"WindSpeed" = "Wind_speed",
"WindDirection" = "Wind_direction",
"Wind_Direction" = "Wind_direction"
)
for (nm in names(rename_map)) {
if (nm %in% names(raw) && !(rename_map[[nm]] %in% names(raw))) {
names(raw)[names(raw) == nm] <- rename_map[[nm]]
}
}
needed <- c("Date", "Time", "Wave_size", "Wind_speed", "Wind_direction")
missing_cols <- setdiff(needed, names(raw))
if (length(missing_cols) > 0) {
stop("Missing required columns in CSV: ", paste(missing_cols, collapse = ", "))
}
parse_fr_datetime <- function(date_fr, time_hm,
year = as.integer(format(Sys.Date(), "%Y"))) {
s <- trimws(date_fr)
parts <- strsplit(s, "\\s+")[[1]]
if (length(parts) < 3) return(as.POSIXct(NA))
day_num <- parts[2]
month_fr <- parts[3]
months <- c(
"Janvier" = 1,
"Février" = 2, "Fevrier" = 2,
"Mars" = 3,
"Avril" = 4,
"Mai" = 5,
"Juin" = 6,
"Juillet" = 7,
"Août" = 8, "Aout" = 8,
"Septembre" = 9,
"Octobre" = 10,
"Novembre" = 11,
"Décembre" = 12, "Decembre" = 12
)
m <- months[[month_fr]]
if (is.null(m)) return(as.POSIXct(NA))
iso <- sprintf("%04d-%02d-%02d %s:00", year, as.integer(m), as.integer(day_num), time_hm)
as.POSIXct(iso, tz = "")
}
# Wave mean from "0.8m - 0.7m" or "0.8 - 0.7"
extract_wave_mean <- function(x) {
x <- gsub("m", "", x, fixed = TRUE)
x <- gsub("\\s+", "", x)
sp <- strsplit(x, "-")
v1 <- suppressWarnings(as.numeric(sapply(sp, function(z) if (length(z) >= 1) z[1] else NA)))
v2 <- suppressWarnings(as.numeric(sapply(sp, function(z) if (length(z) >= 2) z[2] else NA)))
rowMeans(cbind(v1, v2), na.rm = TRUE)
}
# Wind speed from "3km/h" -> 3
extract_wind_speed <- function(x) {
x <- gsub("km/h", "", x, fixed = TRUE)
x <- gsub("\\s+", "", x)
suppressWarnings(as.numeric(x))
}
# Build dataset (single, clean pipeline)
data <- raw %>%
mutate(
Wave_Size_Mean = extract_wave_mean(Wave_size),
Wind_speed_num = extract_wind_speed(Wind_speed),
DateTime = as.POSIXct(unlist(mapply(parse_fr_datetime, Date, Time, SIMPLIFY = FALSE)),
origin = "1970-01-01", tz = "")
) %>%
filter(!is.na(DateTime)) %>%
arrange(DateTime) %>%
mutate(
score_dir = ifelse(grepl("Nord", Wind_direction, ignore.case = TRUE), 3, 0),
score_wave = ifelse(Wave_Size_Mean <= 1.0, 3,
ifelse(Wave_Size_Mean <= 1.5, 2,
ifelse(Wave_Size_Mean <= 2.0, 1, 0))),
score_wind = ifelse(Wind_speed_num <= 10, 3,
ifelse(Wind_speed_num <= 25, 2,
ifelse(Wind_speed_num <= 50, 1, 0))),
Quality = score_dir + score_wave + score_wind
)
# --- Strict constraint: keep only slots with "Nord" in wind direction
data_nord <- data %>%
dplyr::filter(grepl("Nord", Wind_direction, ignore.case = TRUE))
if (nrow(data_nord) == 0) {
# Fallback if no "Nord" slots exist (optional)
best_idx <- which.max(data$Quality)
best_row <- data[best_idx, ]
} else {
best_idx <- which.max(data_nord$Quality)
best_row <- data_nord[best_idx, ]
}
highest_wave <- max(data$Wave_Size_Mean, na.rm = TRUE)
hw_rows <- data %>% filter(Wave_Size_Mean == highest_wave)
quality_pct <- round(100 * best_row$Quality / 9)
tbl <- data %>%
transmute(
Day = Date,
Time = Time,
Wave_mean_m = round(Wave_Size_Mean, 2),
Wind_kmh = round(Wind_speed_num, 0),
Direction = Wind_direction
)
# Plots (no ggplot title; the flexdashboard section titles are enough)
p1 <- ggplot(data, aes(x = DateTime, y = Wave_Size_Mean)) +
geom_line(linewidth = 1) +
geom_point(size = 1.2, alpha = 0.7) +
labs(x = NULL, y = "Mean wave size (m)") +
scale_x_datetime(date_breaks = "3 days", date_labels = "%d %b") +
scale_y_continuous(expand = expansion(mult = c(0.02, 0.05))) +
theme_minimal(base_size = 12) +
theme(
panel.grid.minor = element_blank(),
axis.text.x = element_text(angle = 35, hjust = 1)
)
p2 <- ggplot(data, aes(x = DateTime, y = Wind_speed_num)) +
geom_line(linewidth = 1) +
geom_point(size = 1.2, alpha = 0.7) +
labs(x = NULL, y = "Wind speed (km/h)") +
scale_x_datetime(date_breaks = "3 days", date_labels = "%d %b") +
scale_y_continuous(expand = expansion(mult = c(0.02, 0.05))) +
theme_minimal(base_size = 12) +
theme(
panel.grid.minor = element_blank(),
axis.text.x = element_text(angle = 35, hjust = 1)
)
```
## Row {data-height="230"}
------------------------------------------------------------------------
### Best moment {data-width="7"}
```{r}
valueBox(
value = paste0(best_row$Date, " @ ", best_row$Time),
caption = paste0(
"Quality ", best_row$Quality, "/9",
" | Wave ", round(best_row$Wave_Size_Mean, 2), " m",
" | Wind ", best_row$Wind_speed_num, " km/h",
" | ", best_row$Wind_direction
),
icon = "fa-star",
color = "success"
)
```
### Sea quality (best moment) {data-width="2"}
```{r}
gauge(
value = quality_pct,
min = 0,
max = 100,
symbol = "%",
label = "Sea quality"
)
```
### Highest wave {data-width="3"}
```{r}
valueBox(
value = paste0(round(highest_wave, 2), " m"),
caption = paste0("Scheduled for ", hw_rows$Date[1]),
icon = "fa-arrow-up",
color = "info"
)
```
## Row {data-height="420"}
------------------------------------------------------------------------
### Wave size (mean) over time {data-width="6"}
```{r}
ggplotly(p1, tooltip = c("x", "y")) %>%
layout(
hovermode = "x unified",
margin = list(l = 55, r = 15, t = 40, b = 70),
xaxis = list(nticks = 6, tickangle = -35)
)
```
### Wind speed over time {data-width="6"}
```{r}
ggplotly(p2, tooltip = c("x", "y")) %>%
layout(
hovermode = "x unified",
margin = list(l = 55, r = 15, t = 40, b = 70),
xaxis = list(nticks = 6, tickangle = -35)
)
```
## Row {data-height="350"}
------------------------------------------------------------------------
### Summary table
```{r summary-table, echo=FALSE, message=FALSE, warning=FALSE}
DT::datatable(
tbl,
rownames = FALSE,
class = "stripe hover compact",
extensions = c("Scroller"),
options = list(
pageLength = 12,
lengthMenu = c(8, 12, 20, 50),
searching = TRUE,
ordering = TRUE,
autoWidth = FALSE,
scrollX = TRUE,
scrollY = "240px",
scrollCollapse = TRUE,
deferRender = TRUE,
scroller = TRUE,
dom = "lftip",
initComplete = DT::JS(
"function(settings, json){ setTimeout(() => { this.api().columns.adjust(); }, 50); }"
)
)
)
```